/**
 * $RCSfile$
 * $Revision$
 * $Date$
 *
 * Copyright 2003-2006 Jive Software.
 *
 * All rights reserved. Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jivesoftware.smackx.filetransfer;

import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

import org.jivesoftware.smack.Connection;
import org.jivesoftware.smack.ConnectionListener;
import org.jivesoftware.smack.PacketCollector;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.filter.PacketIDFilter;
import org.jivesoftware.smack.packet.IQ;
import org.jivesoftware.smack.packet.Packet;
import org.jivesoftware.smack.packet.XMPPError;
import org.jivesoftware.smackx.Form;
import org.jivesoftware.smackx.FormField;
import org.jivesoftware.smackx.ServiceDiscoveryManager;
import org.jivesoftware.smackx.bytestreams.ibb.InBandBytestreamManager;
import org.jivesoftware.smackx.bytestreams.socks5.Socks5BytestreamManager;
import org.jivesoftware.smackx.packet.DataForm;
import org.jivesoftware.smackx.packet.StreamInitiation;

/**
 * Manages the negotiation of file transfers according to JEP-0096. If a file is
 * being sent the remote user chooses the type of stream under which the file
 * will be sent.
 * 
 * @author Alexander Wenckus
 * @see <a href="http://xmpp.org/extensions/xep-0096.html">XEP-0096: SI File
 *      Transfer</a>
 */
public class FileTransferNegotiator {

	// Static

	private static final String[] NAMESPACE = {
			"http://jabber.org/protocol/si/profile/file-transfer",
			"http://jabber.org/protocol/si" };

	private static final Map<Connection, FileTransferNegotiator> transferObject = new ConcurrentHashMap<Connection, FileTransferNegotiator>();

	private static final String STREAM_INIT_PREFIX = "jsi_";

	protected static final String STREAM_DATA_FIELD_NAME = "stream-method";

	private static final Random randomGenerator = new Random();

	/**
	 * A static variable to use only offer IBB for file transfer. It is
	 * generally recommend to only set this variable to true for testing
	 * purposes as IBB is the backup file transfer method and shouldn't be used
	 * as the only transfer method in production systems.
	 */
	public static boolean IBB_ONLY = (System.getProperty("ibb") != null);// true;

	/**
	 * Returns the file transfer negotiator related to a particular connection.
	 * When this class is requested on a particular connection the file transfer
	 * service is automatically enabled.
	 * 
	 * @param connection
	 *            The connection for which the transfer manager is desired
	 * @return The IMFileTransferManager
	 */
	public static FileTransferNegotiator getInstanceFor(
			final Connection connection) {
		if (connection == null) {
			throw new IllegalArgumentException("Connection cannot be null");
		}
		if (!connection.isConnected()) {
			return null;
		}

		if (transferObject.containsKey(connection)) {
			return transferObject.get(connection);
		} else {
			FileTransferNegotiator transfer = new FileTransferNegotiator(
					connection);
			setServiceEnabled(connection, true);
			transferObject.put(connection, transfer);
			return transfer;
		}
	}

	/**
	 * Enable the Jabber services related to file transfer on the particular
	 * connection.
	 * 
	 * @param connection
	 *            The connection on which to enable or disable the services.
	 * @param isEnabled
	 *            True to enable, false to disable.
	 */
	public static void setServiceEnabled(final Connection connection,
			final boolean isEnabled) {
		ServiceDiscoveryManager manager = ServiceDiscoveryManager
				.getInstanceFor(connection);

		List<String> namespaces = new ArrayList<String>();
		namespaces.addAll(Arrays.asList(NAMESPACE));
		namespaces.add(InBandBytestreamManager.NAMESPACE);
		if (!IBB_ONLY) {
			namespaces.add(Socks5BytestreamManager.NAMESPACE);
		}

		for (String namespace : namespaces) {
			if (isEnabled) {
				if (!manager.includesFeature(namespace)) {
					manager.addFeature(namespace);
				}
			} else {
				manager.removeFeature(namespace);
			}
		}

	}

	/**
	 * Checks to see if all file transfer related services are enabled on the
	 * connection.
	 * 
	 * @param connection
	 *            The connection to check
	 * @return True if all related services are enabled, false if they are not.
	 */
	public static boolean isServiceEnabled(final Connection connection) {
		ServiceDiscoveryManager manager = ServiceDiscoveryManager
				.getInstanceFor(connection);

		List<String> namespaces = new ArrayList<String>();
		namespaces.addAll(Arrays.asList(NAMESPACE));
		namespaces.add(InBandBytestreamManager.NAMESPACE);
		if (!IBB_ONLY) {
			namespaces.add(Socks5BytestreamManager.NAMESPACE);
		}

		for (String namespace : namespaces) {
			if (!manager.includesFeature(namespace)) {
				return false;
			}
		}
		return true;
	}

	/**
	 * A convenience method to create an IQ packet.
	 * 
	 * @param ID
	 *            The packet ID of the
	 * @param to
	 *            To whom the packet is addressed.
	 * @param from
	 *            From whom the packet is sent.
	 * @param type
	 *            The IQ type of the packet.
	 * @return The created IQ packet.
	 */
	public static IQ createIQ(final String ID, final String to,
			final String from, final IQ.Type type) {
		IQ iqPacket = new IQ() {
			public String getChildElementXML() {
				return null;
			}
		};
		iqPacket.setPacketID(ID);
		iqPacket.setTo(to);
		iqPacket.setFrom(from);
		iqPacket.setType(type);

		return iqPacket;
	}

	/**
	 * Returns a collection of the supported transfer protocols.
	 * 
	 * @return Returns a collection of the supported transfer protocols.
	 */
	public static Collection<String> getSupportedProtocols() {
		List<String> protocols = new ArrayList<String>();
		protocols.add(InBandBytestreamManager.NAMESPACE);
		if (!IBB_ONLY) {
			protocols.add(Socks5BytestreamManager.NAMESPACE);
		}
		return Collections.unmodifiableList(protocols);
	}

	// non-static

	private final Connection connection;

	private final StreamNegotiator byteStreamTransferManager;

	private final StreamNegotiator inbandTransferManager;

	private FileTransferNegotiator(final Connection connection) {
		configureConnection(connection);

		this.connection = connection;
		byteStreamTransferManager = new Socks5TransferNegotiator(connection);
		inbandTransferManager = new IBBTransferNegotiator(connection);
	}

	private void configureConnection(final Connection connection) {
		connection.addConnectionListener(new ConnectionListener() {
			public void connectionClosed() {
				cleanup(connection);
			}

			public void connectionClosedOnError(Exception e) {
				cleanup(connection);
			}

			public void reconnectionFailed(Exception e) {
				// ignore
			}

			public void reconnectionSuccessful() {
				// ignore
			}

			public void reconnectingIn(int seconds) {
				// ignore
			}
		});
	}

	private void cleanup(final Connection connection) {
		if (transferObject.remove(connection) != null) {
			inbandTransferManager.cleanup();
		}
	}

	/**
	 * Selects an appropriate stream negotiator after examining the incoming
	 * file transfer request.
	 * 
	 * @param request
	 *            The related file transfer request.
	 * @return The file transfer object that handles the transfer
	 * @throws XMPPException
	 *             If there are either no stream methods contained in the
	 *             packet, or there is not an appropriate stream method.
	 */
	public StreamNegotiator selectStreamNegotiator(FileTransferRequest request)
			throws XMPPException {
		StreamInitiation si = request.getStreamInitiation();
		FormField streamMethodField = getStreamMethodField(si
				.getFeatureNegotiationForm());

		if (streamMethodField == null) {
			String errorMessage = "No stream methods contained in packet.";
			XMPPError error = new XMPPError(XMPPError.Condition.bad_request,
					errorMessage);
			IQ iqPacket = createIQ(si.getPacketID(), si.getFrom(), si.getTo(),
					IQ.Type.ERROR);
			iqPacket.setError(error);
			connection.sendPacket(iqPacket);
			throw new XMPPException(errorMessage, error);
		}

		// select the appropriate protocol

		StreamNegotiator selectedStreamNegotiator;
		try {
			selectedStreamNegotiator = getNegotiator(streamMethodField);
		} catch (XMPPException e) {
			IQ iqPacket = createIQ(si.getPacketID(), si.getFrom(), si.getTo(),
					IQ.Type.ERROR);
			iqPacket.setError(e.getXMPPError());
			connection.sendPacket(iqPacket);
			throw e;
		}

		// return the appropriate negotiator

		return selectedStreamNegotiator;
	}

	private FormField getStreamMethodField(DataForm form) {
		FormField field = null;
		for (Iterator<FormField> it = form.getFields(); it.hasNext();) {
			field = it.next();
			if (field.getVariable().equals(STREAM_DATA_FIELD_NAME)) {
				break;
			}
			field = null;
		}
		return field;
	}

	private StreamNegotiator getNegotiator(final FormField field)
			throws XMPPException {
		String variable;
		boolean isByteStream = false;
		boolean isIBB = false;
		for (Iterator<FormField.Option> it = field.getOptions(); it.hasNext();) {
			variable = it.next().getValue();
			if (variable.equals(Socks5BytestreamManager.NAMESPACE) && !IBB_ONLY) {
				isByteStream = true;
			} else if (variable.equals(InBandBytestreamManager.NAMESPACE)) {
				isIBB = true;
			}
		}

		if (!isByteStream && !isIBB) {
			XMPPError error = new XMPPError(XMPPError.Condition.bad_request,
					"No acceptable transfer mechanism");
			throw new XMPPException(error.getMessage(), error);
		}

		// if (isByteStream && isIBB &&
		// field.getType().equals(FormField.TYPE_LIST_MULTI)) {
		if (isByteStream && isIBB) {
			return new FaultTolerantNegotiator(connection,
					byteStreamTransferManager, inbandTransferManager);
		} else if (isByteStream) {
			return byteStreamTransferManager;
		} else {
			return inbandTransferManager;
		}
	}

	/**
	 * Reject a stream initiation request from a remote user.
	 * 
	 * @param si
	 *            The Stream Initiation request to reject.
	 */
	public void rejectStream(final StreamInitiation si) {
		XMPPError error = new XMPPError(XMPPError.Condition.forbidden,
				"Offer Declined");
		IQ iqPacket = createIQ(si.getPacketID(), si.getFrom(), si.getTo(),
				IQ.Type.ERROR);
		iqPacket.setError(error);
		connection.sendPacket(iqPacket);
	}

	/**
	 * Returns a new, unique, stream ID to identify a file transfer.
	 * 
	 * @return Returns a new, unique, stream ID to identify a file transfer.
	 */
	public String getNextStreamID() {
		StringBuilder buffer = new StringBuilder();
		buffer.append(STREAM_INIT_PREFIX);
		buffer.append(Math.abs(randomGenerator.nextLong()));

		return buffer.toString();
	}

	/**
	 * Send a request to another user to send them a file. The other user has
	 * the option of, accepting, rejecting, or not responding to a received file
	 * transfer request.
	 * <p/>
	 * If they accept, the packet will contain the other user's chosen stream
	 * type to send the file across. The two choices this implementation
	 * provides to the other user for file transfer are <a
	 * href="http://www.jabber.org/jeps/jep-0065.html">SOCKS5 Bytestreams</a>,
	 * which is the preferred method of transfer, and <a
	 * href="http://www.jabber.org/jeps/jep-0047.html">In-Band Bytestreams</a>,
	 * which is the fallback mechanism.
	 * <p/>
	 * The other user may choose to decline the file request if they do not
	 * desire the file, their client does not support JEP-0096, or if there are
	 * no acceptable means to transfer the file.
	 * <p/>
	 * Finally, if the other user does not respond this method will return null
	 * after the specified timeout.
	 * 
	 * @param userID
	 *            The userID of the user to whom the file will be sent.
	 * @param streamID
	 *            The unique identifier for this file transfer.
	 * @param fileName
	 *            The name of this file. Preferably it should include an
	 *            extension as it is used to determine what type of file it is.
	 * @param size
	 *            The size, in bytes, of the file.
	 * @param desc
	 *            A description of the file.
	 * @param responseTimeout
	 *            The amount of time, in milliseconds, to wait for the remote
	 *            user to respond. If they do not respond in time, this
	 * @return Returns the stream negotiator selected by the peer.
	 * @throws XMPPException
	 *             Thrown if there is an error negotiating the file transfer.
	 */
	public StreamNegotiator negotiateOutgoingTransfer(final String userID,
			final String streamID, final String fileName, final long size,
			final String desc, int responseTimeout) throws XMPPException {
		StreamInitiation si = new StreamInitiation();
		si.setSesssionID(streamID);
		si.setMimeType(URLConnection.guessContentTypeFromName(fileName));

		StreamInitiation.File siFile = new StreamInitiation.File(fileName, size);
		siFile.setDesc(desc);
		si.setFile(siFile);

		si.setFeatureNegotiationForm(createDefaultInitiationForm());

		si.setFrom(connection.getUser());
		si.setTo(userID);
		si.setType(IQ.Type.SET);

		PacketCollector collector = connection
				.createPacketCollector(new PacketIDFilter(si.getPacketID()));
		connection.sendPacket(si);
		Packet siResponse = collector.nextResult(responseTimeout);
		collector.cancel();

		if (siResponse instanceof IQ) {
			IQ iqResponse = (IQ) siResponse;
			if (iqResponse.getType().equals(IQ.Type.RESULT)) {
				StreamInitiation response = (StreamInitiation) siResponse;
				return getOutgoingNegotiator(getStreamMethodField(response
						.getFeatureNegotiationForm()));

			} else if (iqResponse.getType().equals(IQ.Type.ERROR)) {
				throw new XMPPException(iqResponse.getError());
			} else {
				throw new XMPPException("File transfer response unreadable");
			}
		} else {
			return null;
		}
	}

	private StreamNegotiator getOutgoingNegotiator(final FormField field)
			throws XMPPException {
		String variable;
		boolean isByteStream = false;
		boolean isIBB = false;
		for (Iterator<String> it = field.getValues(); it.hasNext();) {
			variable = it.next();
			if (variable.equals(Socks5BytestreamManager.NAMESPACE) && !IBB_ONLY) {
				isByteStream = true;
			} else if (variable.equals(InBandBytestreamManager.NAMESPACE)) {
				isIBB = true;
			}
		}

		if (!isByteStream && !isIBB) {
			XMPPError error = new XMPPError(XMPPError.Condition.bad_request,
					"No acceptable transfer mechanism");
			throw new XMPPException(error.getMessage(), error);
		}

		if (isByteStream || isIBB) {
			return new FaultTolerantNegotiator(connection,
					byteStreamTransferManager, inbandTransferManager);
		} else if (isByteStream) {
			return byteStreamTransferManager;
		} else {
			return inbandTransferManager;
		}
	}

	private DataForm createDefaultInitiationForm() {
		DataForm form = new DataForm(Form.TYPE_FORM);
		FormField field = new FormField(STREAM_DATA_FIELD_NAME);
		field.setType(FormField.TYPE_LIST_SINGLE);
		if (!IBB_ONLY) {
			field.addOption(new FormField.Option(
					Socks5BytestreamManager.NAMESPACE));
		}
		field.addOption(new FormField.Option(InBandBytestreamManager.NAMESPACE));
		form.addField(field);
		return form;
	}
}
